iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0

先前,我們把 main 放在 agent.py,而為了做到「架構分層」,應該要把它獨立出去才行!

這個部分:

# src/meowgent/agent.py
...
if __name__ == "__main__":
	...

接下來幾天,架構會變成這樣:

  • providers/ollama_provider.py 請求模型。
  • agent.py 整合 provider。
  • tool.py 負責工具函數。
  • cli/main.py 作為程式進入點。

這篇我們先來把 agent.py 升級成類別吧!


初始化 Agent()

首先進行初始化的部分,未來建立 Agent 物件時會傳入 OllamaProvider 物件:

  • self.max_turns:限制迴圈輪數,為了防止模型因沒得到結果而無限調用工具。
  • self.executor:是為了可以並發處理多個工具調用(模型同時傳來多個工具請求可同時執行)。
# src/meowgent/agent.py
import re
from providers import LLMProvider, LLMResponse, ToolCall
from tool import execute_tool, TOOL_REGISTRY
from prompt import get_system_prompt
from typing import List, Callable, Optional, Iterator, Tuple
import time
import json
from concurrent.futures import ThreadPoolExecutor

class Agent():
    def __init__(self, provider: LLMProvider, max_turns: int = 20):
        self.provider = provider # 直接傳入 provider = OllamaProvider(model_name)
        self.max_turns = max_turns
        self.history_messages = []
        
        self.executor = ThreadPoolExecutor(max_workers=4)
        # 執行緒池(並發處理多個工具調用)

對話處理函數

接著,我們建立 chat() 方法,提供未來製作的主程序來呼叫。
要做到的事有以下幾件:

  1. main.py 取得輸入,加入多輪歷史
  2. 限制單次對話中的工具調用次數。
  3. 調用模型。
  4. 檢查模型是否思考結束(透過 thinking_finish 進行推理總結,顯示推理耗時)。
  5. 處理並回傳模型流式輸出的推理及回答內容。
  6. 解析模型的工具調用請求(會出現在模型的回答,要將其取出)。
  7. main.py 發送審核,並請求 tool.py 執行 execute_tool()
  8. 模型回答加入多輪歷史
    這篇我們先來完成前五項!

函數初始和輸入加入多輪

tool_approval 的部分,傳入的是一個「可呼叫函數」(從 main.py),要傳入此函數的參數為 strdict,分別是工具名和工具要的參數,最後,回傳是否同意調用的 bool
chat() 回傳的是可迭代的 LLMResponse

# src/meowgent/agent.py
...
class Agent():
	...
	
	def chat(
		self,
		user_input: str,
		tool_approval: Callable[[str, dict], bool]	
	) -> Iterator[LLMResponse]:
		self.history_messages.append(
            {
                "role": "user",
                "content": user_input
            }
        ) # 使用者輸入加入多輪

我們來看一下關於 LLMResponse

定義了六種狀態,以便之後在 main.py 判斷要去渲染什麼,除了狀態是必要的,其他都是視情況而選的:

  • content 是在推理("thinking")及回答("response")時才有。
  • think_time 是在推理結束("thinking_done")時才有。
  • tool_name 則是在工具調用結束("tool_executed""tool_rejected")時才有。
# src/meowgent/providers/base.py
...

@dataclass
class LLMResponse:
    status: Literal[
		"response", "thinking", "thinking_done",
		"tool_calling", "tool_executed", "tool_rejected"
	]
    content: Optional[str] = None
    think_time: Optional[float] = None
    tool_name: Optional[str] = None

迴圈計數限制

一開始 turns 為 0,每進入一次迴圈,就 +1,直到達到限制次數self.max_turns)為止,便不再繼續執行迴圈。

但若只是這樣突然地結束迴圈,模型最後一次調用依然停留在工具調用,還是沒能給出一個回答,所以我們需要在最後一次調用加入新 prompt 來提示「請給出一個回答,別再調用工具了」。

假設限制為 20 次,第 20 次(進入迴圈前 turns = 19),成功通過 while turns < self.max_turns: 接著被 +1,通過 if is_last_turn:turns == self.max_turns)給出最後一次機會的提示。
再來 turns = 20 就被擋住了,就算模型還是堅持要繼續也無法。

# src/meowgent/agent.py
...
class Agent():
	...
	
	def chat(...) -> Iterator[LLMResponse]:
		...
		
		turns = 0
        while turns < self.max_turns: # 模型內迴圈,使用者輸入,模型多次調用
	        turns += 1 # 計數 +1
	        is_last_turn = (turns == self.max_turns) # 是否為最後一輪

            temp_history_messages = list(self.history_messages)
            # 複製一份,阻止無限調用訊息進入多輪歷史
            
            if is_last_turn:
                temp_history_messages.append(
                    {
                        "role": "user",
                        "content": "
		                    [系統提示] 您已達到工具調用次數上限。請勿再調用工具,
		                    直接輸出最終回答,並總結當前進度與遇到的問題。
	                    "
                    }
                )

調用 provider 和迴圈內初始化

呼叫 ollama_provider.py 中的 stream_generate() 來調用模型,並且初始化變數:

responseIterator[StreamChunk] 形式,之後用 for 取內容。

# src/meowgent/agent.py
...
class Agent():
	...
	
	def chat(...) -> Iterator[LLMResponse]:
		...
		while turns < self.max_turns:
			...
			if is_last_turn:
				...
				
			response = self.provider.stream_generate(
	            history_messages=temp_history_messages
	        ) # 請求模型
	            
	        thinking_full_text = "" # 存放推理內容
			result_full_text = "" # 存放回答及工具調用請求
	
			is_thinking = False # 是否處於推理狀態
			think_start_time = None # 推理計時
	
			tools_result: List[Tuple] = [] # 存放 [(ToolCall 物件, 執行結果), ...]
            completed_tools: List[str] = [] # 存放已閉合的工具 raw 字串(下一篇用到的)

思考總結函數

回傳的值是 LLMResponse 形式的,狀態為 "thinking_done" 且包含時間;又或者是 None
nonlocal is_thinking, think_start_time 的用意是可以修改到此函數(thinking_finish())外部(上一層)的變數。

global 是用來修改最外層的變數。

首先,先判斷是否處於推理結束階段,是的對 think_timethink_start_time 做處理(等下馬上解釋計時部分),並回傳 LLMResponse
不處在推理結束(沒通過 if is_thinking:),則回傳 None

# src/meowgent/agent.py
...
class Agent():
	...
	
	def chat(...) -> Iterator[LLMResponse]:
		...
		while turns < self.max_turns:
			...
			tools_result = ...
			
			def thinking_finish() -> Optional[LLMResponse]:
				""" 判斷推理階段是否結束,若結束則計算並回傳推理耗時 """
				
				nonlocal is_thinking, think_start_time
	
				if is_thinking: # 表示為推理結束後進到回答或工具調用階段
	
					think_time = round(time.perf_counter() - think_start_time, 1)
					# 更新計時
	
					is_thinking = False # 把狀態關掉
	
					return LLMResponse(
						status="thinking_done",
						think_time=think_time
					)
	
				return None

處理模型回傳的文字

分為兩種,推理文字和回答文字(包含工具調用請求)。
我們遍歷 response 取出每個 chunk

  • 先來看推理部分:
    還記得我們定義的 StreamChunk 嗎?也就是 stream_generate() 回傳的,.thinking_chunk 正是來自此。
    我們檢查到有 chunk.thinking_chunk 時,把 is_thinking 狀態打開(設 True),接著,如果這是此次調用模型回傳的第一個推理 chunk,用 time.perf_counter() 開始計時,否則不做動作,這裡就是開始計時的邏輯。
    計時搞定後,我們把這個 chunk 的推理文字放入 thinking_full_text 裡,並用 LLMResponse 回傳。

    你可能想問,為什麼要把文字內容「不斷地疊加做回傳」,而不是只回傳這個 chunk 的內容?這是因為,在終端顯示內容時我們採用 live.update() 來做更新(實際上不完全採用 live.update(),但可以先這麼理解),而它的機制是可以想像現在手上只有一塊畫板,每次寫上新東西,舊的就要擦掉,這就是為什麼要用疊加的方式做回傳。

# src/meowgent/agent.py
...
class Agent():
	...
	
	def chat(...) -> Iterator[LLMResponse]:
		...
		while turns < self.max_turns:
			...
			def thinking_finish():
				...
				
			for chunk in response:
				if chunk.thinking_chunk: # 推理
					is_thinking = True
					think_start_time = time.perf_counter() if think_start_time is None else think_start_time 
					# 如果沒開始計時(此次推理第一個 token 出現時)開始計時
	
					# 流式輸出
					thinking_full_text += chunk.thinking_chunk
					yield LLMResponse(
						status="thinking",
						content=thinking_full_text
					)
  • 再來是文字輸出部分:
    進入到回答, 一開始一樣是計時的部分,這裡呼叫了剛剛建的 thinking_finish(),若稍早有進到推理 is_thinking 就已經被設為 True 了,如此便成功進到 if is_thinking: 區塊,用 time.perf_counter() 記錄下推理結束時間扣掉上面推理階段的 think_start_time = time.perf_counter() if ... 所記錄的開始時間,再用 round() 做「四捨六入五成雙」(否則計算出來的時間會是多位小數點的浮點數),然後 yield 先行回傳思考時間,接著,我們先把目前輸出的內容給加進 result_full_text
# src/meowgent/agent.py
...
class Agent():
	...
	
	def chat(...) -> Iterator[LLMResponse]:
		...
		while turns < self.max_turns:
			...
			for chunk in response:
				if chunk.thinking_chunk:
					...
					
				if chunk.content_chunk: # 回答
                    thinking_status = thinking_finish()
                    if thinking_status:
                        yield thinking_status

                    result_full_text += chunk.content_chunk

有沒有發現,這裡只把 result_full_text累加,還沒有回傳?
沒錯!下一篇,就是要來對 result_full_text 做處理(把工具調用從回答中分離出來)。


上一篇
Day 4 加入工具吧 - 下
下一篇
Day 6 - 獨立調用管道 - 中
系列文
手刻 AI Agent!大一新生的 Python 實戰筆記7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言